jellyfin-tools/jellyfin-year-review/web/src/routes/[...path]/+page.svelte
jellyfin-year-review/web/src/routes/[...path]/+page.svelteRaw
1<script>
2 import { onMount } from 'svelte';
3 import { t, message, locale } from '../../lib/i18n.js';
4 import Poster from '../../lib/Poster.svelte';
5 import PlatformBreakdown from '../../lib/PlatformBreakdown.svelte';
6 import Chart from '../../lib/Chart.svelte';
7 import RadarChart from '../../lib/RadarChart.svelte';
8 import ViewingTimeline from '../../lib/ViewingTimeline.svelte';
9 import RankingPosition from '../../lib/RankingPosition.svelte';
10 import Confetti from '../../lib/Confetti.svelte';
11
12 let user = $state(null), years = $state([]), year = $state(''), review = $state(null);
13 let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');
14 let username = $state(''), password = $state(''), slide = $state(0);
15 let touch = null, request = 0;
16 let authMode = $state('jellyfin');
17 const chapters = [
18 { id: 0, name: t("Your viewing recap") },
19 { id: 1, name: t("Your watch time"), availability: 'exactWatchTime' },
20 { id: 2, name: t("Movies watched") },
21 { id: 3, name: t("TV shows watched") },
22 { id: 4, name: t("Unfinished movies"), availability: 'completion' },
23 { id: 14, name: t("Shows you've watched are from platforms...") },
24 { id: 5, name: t("Your top genres") },
25 { id: 6, name: t("Viewing by hour") },
26 { id: 7, name: t("Viewing by weekday") },
27 { id: 8, name: t("Viewing by month") },
28 { id: 9, name: t("Your devices and apps") },
29 { id: 10, name: t("Your longest viewing session"), availability: 'marathon' },
30 { id: 11, name: t("Your viewing profile"), availability: 'personality' },
31 { id: 12, name: t("It's not a competition... but this is your watch-time ranking"), availability: 'ranking' },
32 { id: 13, name: t("Your year in summary") }
33 ];
34 // Navigation and chapter numbering follow only the available cards for this year.
35 let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false));
36 let card = $derived(cards[slide]?.id);
37 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map(value => t(value));
38 const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(value => t(value));
39 const count = (n) => new Intl.NumberFormat(locale).format(n);
40 const hours = (n) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(n / 3600);
41 const duration = (n) => t("{hours} h", { hours: hours(n) });
42 const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea');
43
44 async function api(path, options = {}) {
45 let res, body;
46 try {
47 res = await fetch(path, { credentials: 'same-origin', ...options });
48 body = await res.json();
49 } catch {
50 throw new Error(t("The request could not be completed."));
51 }
52 if (!res.ok) {
53 if (res.status === 401 && path !== '/api/login') { user = null; review = null; }
54 throw Object.assign(new Error(message(body.error) || t("The request could not be completed.")), { status: res.status });
55 }
56 return body;
57 }
58 async function loadReview() {
59 if (!year) return;
60 const current = ++request;
61 loading = true; error = ''; review = null; slide = 0;
62 try {
63 const data = await api(`/api/review?year=${encodeURIComponent(year)}`);
64 if (current !== request) return;
65 if (data.schemaVersion !== 1) throw new Error(t("This review format is not supported. Ask the operator to update the app."));
66 review = data;
67 history.replaceState(null, '', `/review/${year}`);
68 } catch (e) { if (current === request) error = e.message; }
69 finally { if (current === request) loading = false; }
70 }
71 async function bootstrap() {
72 loading = true; error = '';
73 try {
74 const config = await api('/api/config'); authMode = config.authMode;
75 const session = await api('/api/session'); user = session.user; years = session.years;
76 const pathYear = location.pathname.split('/').filter(Boolean)[1];
77 year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || '');
78 if (year) await loadReview();
79 } catch (e) { if (user || e.status !== 401) error = e.message; }
80 finally { loading = false; }
81 }
82 async function login(event) {
83 event.preventDefault(); signingIn = true; error = ''; notice = '';
84 try {
85 await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
86 password = ''; await bootstrap();
87 } catch (e) { error = e.message; }
88 finally { password = ''; signingIn = false; }
89 }
90 async function logout() {
91 notice = ''; error = '';
92 try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
93 catch (e) { notice = e.message; }
94 finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); }
95 }
96 function navigate(delta) { slide = Math.max(0, Math.min(cards.length - 1, slide + delta)); }
97 function keydown(event) {
98 if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;
99 if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); }
100 if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); }
101 if (event.key === 'Home') { event.preventDefault(); slide = 0; }
102 if (event.key === 'End') { event.preventDefault(); slide = cards.length - 1; }
103 }
104 function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; }
105 function pointerup(event) {
106 if (!touch) return;
107 const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null;
108 if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1);
109 }
110 onMount(bootstrap);
111</script>
112
113<svelte:head><title>{t("Jellyfin Year Review")}</title><meta name="description" content={t("Your private Jellyfin year in movies and television.")} /></svelte:head>
114<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} />
115
116<div class="app-shell" class:login-only={!user}>
117 {#if user}
118 <header class="topbar">
119 <a class="brand" href="/" aria-label={t("Jellyfin Year Review home")}><span class="brand-icon">{t("J")}</span><span>{t("Jellyfin")} <span class="brand-light">{t("Year Review")}</span><small>{t("A YEAR WORTH REPLAYING")}</small></span></a>
120 {#if user}
121 <div class="account">
122 {#if years.length > 1}<label class="year-picker">{t("Year")}<select aria-label={t("Review year")} bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if}
123 <button class="quiet" onclick={logout}>{t("Sign out")}</button>
124 </div>
125 {/if}
126 </header>
127 {/if}
128
129 <main>
130 {#if user}
131 {#if notice}<p class="message" role="status">{notice}</p>{/if}
132 {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>{t("Try again")}</button></div>{/if}
133 {/if}
134 {#if !user}
135 <section class="login-layout" aria-label={t("Sign in")}>
136 <form class="login-panel" onsubmit={login}>
137 <h1 style="white-space: pre-line">{t("Your year,\nreplayed.")}</h1><p>{t("Sign in with your existing Jellyfin account.")}</p>
138 {#if notice}<p class="message" role="status">{notice}</p>{/if}
139 {#if error}<div class="message error" role="alert">{error}</div>{/if}
140 <label for="username">{t("Jellyfin username")}</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} />
141 <label for="password">{t("Password")}</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} />
142 <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? t("Loading…") : signingIn ? t("Signing in…") : t("Roll the credits")} <span aria-hidden="true">↗</span></button>
143 <small class="login-note">{authMode === 'offline' ? t("Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.") : t("Your credentials are validated by your Jellyfin server. Your review stays private.")}</small>
144 </form>
145 </section>
146 {:else if loading}
147 <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>{t("Setting the scene…")}</h1><p role="status">{t("Loading your private review")}</p></section>
148 {:else if !years.length}
149 <section class="state-screen"><p class="eyebrow">{t("NOT QUITE SHOWTIME")}</p><h1>{t("Your story is on its way.")}</h1><p>{t("No review has been generated for your account. Ask your server operator to generate a year.")}</p><button class="primary" onclick={bootstrap}>{t("Check again")}</button></section>
150 {:else if review}
151 <div class="review-meta"><span>{review.year} / {user.name}</span></div>
152 <nav class="progress" aria-label={t("Review chapters")}>{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => slide = i}><span></span></button>{/each}</nav>
153 <section class="story tone-{slide % 4}" aria-roledescription={t("carousel")} aria-label={t("Your yearly review")} onpointerdown={pointerdown}>
154 <div class="story-orbit" aria-hidden="true"></div>
155 <div class="story-body" aria-live="polite" aria-atomic="true">
156 <p class="eyebrow">{t("CHAPTER {number} / {name}", { number: String(slide + 1).padStart(2, '0'), name: cards[slide].name.toLocaleUpperCase(locale) })}</p>
157 {#if card === 0}
158 <span class="year-stamp">{review.year}</span><h1>{t("Your viewing recap")}</h1><p class="story-lede">{t(review.records ? "{name}, see the movies, TV shows, and viewing habits that made up your year." : "{name}, no viewing activity was recorded for this year.", { name: user.name })}</p><button class="primary" onclick={() => navigate(1)}>{t("View my recap")} <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span>
159 {:else if card === 1}
160 <h2>{t("Your watch time")}</h2><div class="big-stat">{hours(review.seconds)}<span>{t(review.availability.exactWatchTime.available ? "hours watched" : "hours watched · end-attributed estimate")}</span></div>
161 <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>{t("Movies")}</span></div><div><b>{duration(review.tvSeconds)}</b><span>{t("Television")}</span></div><div><b>{count(review.records)}</b><span>{t("Activity records")}</span></div><div><b>{count(review.uniqueTitles)}</b><span>{t("Unique movies & episodes")}</span></div></div>
162 {:else if card === 2 || card === 3}
163 {@const items = card === 2 ? review.movies : review.shows}
164 <h2>{card === 2 ? t("Movies watched") : t("TV shows watched")}</h2>
165 <p>{t("Everything you watched in {year}, grouped by month.", { year: review.year })}</p>
166 <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'movies' : 'shows'} />
167 {:else if card === 4}
168 <h2>{t("Unfinished movies")}</h2><p class="story-lede">{t("Movies where you watched less than {percent}% of the runtime.", { percent: review.rules.completionPercent })}</p>
169 {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{t("{percent}% of the movie watched", { percent: Math.round(item.completionPercent) })}</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? t("No unfinished movies found in supported records.") : t("Completion is unavailable without runtime and actual position ranges.")}</p>{/if}
170 {:else if card === 14}
171 <h2>{t("Shows you've watched are from platforms...")}</h2>
172 <p>{t("Distinct TV shows watched, grouped by platform.")}</p>
173 <PlatformBreakdown platforms={review.platforms ?? []} records={review.records} />
174 {:else if card === 5}
175 <h2>{t("Your top genres")}</h2><p>{t("Genres ranked by watch time.")}</p>
176 {#if review.genres.length}
177 {@const values = review.genres.slice(0, 7).map(v => v.seconds / 3600)}
178 {@const labels = review.genres.slice(0, 7).map(v => message(v.name))}
179 <Chart {values} {labels} metric={t("h")} />
180 <RadarChart {values} {labels} metric={t("h")} />
181 {:else}<p class="empty-note">{t("No genre history is available.")}</p>{/if}
182 {:else if card === 6}
183 <h2>{t("Viewing by hour")}</h2><p>{t("Your viewing activity across the hours of the day.")}</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} /></div>
184 {:else if card === 7}
185 <h2>{t("Viewing by weekday")}</h2><p>{t("Your viewing activity for each day of the week.")}</p><Chart values={[...review.weekdays.slice(1), review.weekdays[0]]} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
186 {:else if card === 8}
187 <h2>{t("Viewing by month")}</h2><p>{t("Your viewing activity for each month of {year}.", { year: review.year })}</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
188 {:else if card === 9}
189 <h2>{t("Your devices and apps")}</h2><p>{t("Your most-used devices and apps, ranked by watch time.")}</p><div class="two-columns"><div><h3>{t("Devices")}</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div><div><h3>{t("Apps & clients")}</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div></div>
190 {:else if card === 10}
191 <h2>{t("Your longest viewing session")}</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>{t("hours watched in your longest session")}</span></div><p class="story-lede">{t("{duration} elapsed · {records} records", { duration: duration(review.marathon.elapsedSeconds), records: count(review.marathon.records) })}</p><p>{new Date(review.marathon.start).toLocaleString(locale, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{message(review.availability.marathon.reason)}</p>{/if}
192 {:else if card === 11}
193 <h2>{t("Your viewing profile")}</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{message(review.personality.name)}</h3><p class="story-lede">{message(review.personality.explanation)}</p>{:else}<p class="empty-note">{message(review.availability.personality.reason)}</p>{/if}
194 {:else if card === 12}
195 <h2>{t("It's not a competition... but this is your watch-time ranking")}</h2><p>{t("Your total watch time compared with other eligible viewers.")}</p>{#if review.ranking}<div class="big-stat">#{review.ranking.rank}<span>{t("of {count} eligible viewers", { count: count(review.ranking.cohort) })}</span></div><RankingPosition ranking={review.ranking} /><div class="stat-grid"><div><b>{Math.round(review.ranking.percentile)}%</b><span>{t("Viewers with less watch time")}</span></div><div><b>{duration(review.seconds)}</b><span>{t("Your total hours watched · movies & TV shows")}</span></div></div>{:else}<p class="empty-note">{message(review.availability.ranking.reason)}</p>{/if}
196 {:else}
197 <p class="year-stamp">{t("SUMMARY / {year}", { year: review.year })}</p><h2>{t("Your year in summary")}</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>{t("Your watch time")}</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{message(review.personality?.name) || t("No viewing profile")}</b><span>{t("Your viewing profile")}</span></div>{/if}</div><p class="story-lede">{t("You have reached the end of your viewing recap.")}</p><button class="primary" onclick={() => slide = 0}>{t("View recap again ↺")}</button>
198 {/if}
199 </div>
200 <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label={t("Previous chapter")} onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label={t("Next chapter")} onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div>
201 </section>
202 <details class="source-notes"><summary>{t("Behind your review · source & availability")}</summary><p>{message(review.source)}</p><p>{message(review.coverage)}</p><p>{t("Snapshot: {date} · Format v{version}", { date: new Date(review.generatedAt).toISOString(), version: review.schemaVersion })}</p>{#each review.warnings as warning}<p>{message(warning)}</p>{/each}</details>
203 {:else}<section class="state-screen"><h1>{t("Let’s try that again.")}</h1><button class="primary" onclick={bootstrap}>{t("Reload review")}</button></section>{/if}
204 </main>
205 {#if user}<footer><span>{t("MADE FOR THE LOVE OF THE WATCH.")}</span></footer>{/if}
206</div>
207
208{#if user && !loading && review?.ranking && card === 12}<Confetti />{/if}